Skip to content

feat(gateway): safely rotate ACME credentials - #1004

Closed
kvinwang wants to merge 3 commits into
masterfrom
codex/fix-gateway-corrupt-acme-credentials
Closed

feat(gateway): safely rotate ACME credentials#1004
kvinwang wants to merge 3 commits into
masterfrom
codex/fix-gateway-corrupt-acme-credentials

Conversation

@kvinwang

@kvinwang kvinwang commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Problem

A corrupt persisted ACME credential must not take down Gateway or discard certificates that are already serving traffic. Operators also need a controlled way to replace the shared ACME account and its account-bound CAA records.

Solution

  • Reject malformed ACME credential JSON for the current issuance/renewal attempt without affecting Gateway startup, TLS serving, or other certificate state.
  • Add the admin-only RotateAcmeCredentials RPC.
  • Create the replacement ACME account before changing shared state.
  • Update issue and issuewild CAA records for every configured ZT domain, using each domain's configured DNS credential.
  • Publish the replacement credential to WaveKV only after all CAA updates succeed.
  • Never return private ACME credential material through the API.
  • Serialize rotations handled by one node. WaveKV 1.0 has no CAS, so operators must not invoke rotation concurrently through multiple gateway nodes.

Gateway does not retain a long-lived ACME client. Each issuance or renewal reloads credentials from WaveKV, so nodes automatically use the replacement credential on their next attempt after synchronization.

Failure isolation

CAA/account creation failures abort rotation without replacing the persisted credential. Existing certificates remain loaded and continue serving traffic. Corrupt credentials fail only the affected ACME operation and can be repaired through the rotation API.

Verification

  • cargo check -p dstack-gateway
  • cargo test -p dstack-gateway credential_tests -- --nocapture
  • git diff --check

Copilot AI lite review requested due to automatic review settings August 5, 2026 05:46
@kvinwang

kvinwang commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by the restored and unstacked #935.

@kvinwang kvinwang closed this Aug 5, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds an admin-controlled mechanism in dstack-gateway to rotate the shared ACME account credentials while keeping gateway startup and current TLS serving resilient to corrupt persisted ACME credential JSON.

Changes:

  • Add an admin-only RotateAcmeCredentials RPC that creates a new ACME account, updates per-domain CAA records, then publishes the replacement credentials to WaveKV.
  • Make ACME credential JSON parsing “fail closed” (invalid JSON now surfaces as an error for the affected ACME operation, rather than silently falling back).
  • Refactor DNS-credential access to support using each ZT domain’s configured DNS credential during rotation.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
dstack/gateway/src/main_service.rs Exposes a proxy method that delegates ACME credential rotation to the certbot manager.
dstack/gateway/src/distributed_certbot.rs Implements rotation flow, adds stricter ACME credential JSON validation, and introduces DNS client helper logic.
dstack/gateway/src/admin_service.rs Wires the new admin RPC handler returning account URI + updated domain count.
dstack/gateway/rpc/proto/gateway_rpc.proto Adds RotateAcmeCredentialsResponse and the RotateAcmeCredentials RPC definition.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +118 to +121
client
.set_caa_records(&[format!("*.{}", config.domain)])
.await
.with_context(|| format!("failed to update CAA for {}", config.domain))?;
Comment on lines 447 to +451
// Get DNS credential (from config or default)
let dns_cred = if let Some(ref cred_id) = config.dns_cred_id {
self.kv_store
.get_dns_credential(cred_id)
.context("specified DNS credential not found")?
} else {
self.kv_store
.get_default_dns_credential()
.context("no default DNS credential configured")?
};
let dns_cred = dns_credential_for(&self.kv_store, config)?;

// Create DNS client based on provider
let dns01_client = match &dns_cred.provider {
DnsProvider::Cloudflare { api_token, api_url } => {
Dns01Client::new_cloudflare(domain.to_string(), api_token.clone(), api_url.clone())
.await?
}
};
let dns01_client = self.dns_client(domain, config).await?;
Comment on lines +77 to +80
pub async fn rotate_acme_credentials(&self) -> Result<(String, usize)> {
let Ok(_guard) = self.caa_lock.try_lock() else {
bail!("ACME credential rotation or CAA reconciliation is already in progress");
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants